上一篇我們講完了單一欄位的自定義驗證,這篇則要來討論跨欄位之間的驗證。
跨欄位驗證同樣是 API 開發中十分常見的需求,例如註冊帳號時,要保證「密碼」與「確認密碼」兩個欄位內容相同;選擇日期區間時,開始日期不能晚於結束日期等。
這些驗證場景無法透過單一欄位驗證實現,因為它們需要同時檢查多個欄位之間的邏輯關聯,來確保整體資料的一致性和正確性。
本文將介紹如何透過 Pydantic 來實現跨欄位驗證需求——以「確認密碼」為例,展示這個功能的實際應用。
本文所有的程式碼改動,可參考這個 PR。
其實,無論是單一欄位還是跨欄位的自定義驗證,都不一定要藉由 Pydantic 來完成。
理論上,資料驗證可以直接在 view 函式中進行,例如取出輸入的欄位值,手動驗證它的合法性。跨欄位驗證也是如此。
然而,這是一種方便但「粗糙」的做法——只適合用在驗證邏輯非常單純的情況。
透過 Pydantic 進行資料驗證,則能夠帶來一個明顯的好處:關注點分離。
關注點分離(Separation of Concerns)是一種設計原則。主張將程式中不同功能的職責劃分到獨立的模組或層次中。
每個模組主要專注於一個具體的方向或目標,從而避免把多個不同的功能耦合在一起。這樣的劃分可以讓程式更易於測試、維護和擴充。
依照關注點分離,資料驗證的邏輯應該集中在 Schema,而不是在 view 函式中進行。
如此一來,view 可以專注於處理核心業務邏輯,而將資料驗證交由專門的元件負責。
透過 Pydantic 的驗證機制,我們可以實現關注點分離,讓資料驗證與業務邏輯分開,這不僅提升了程式碼的結構,也讓開發流程更加清楚、穩定。
我們要實作一個非常簡單,但足以充分說明跨欄位驗證價值的功能:確認密碼。
先回顧上一篇結束時,「新增使用者」API 的請求 Schema 內容:
class CreateUserRequest(Schema):
username: str = Field(examples=['Alice'])
email: str = Field(examples=['alice@example.com'])
password: str = Field(min_length=8, examples=['password123'])
bio: str | None = Field(
default=None, examples=['Hello, I am Alice.'])
...
這個 Schema 的設計,顯然有所不足。
因為用戶註冊時,密碼通常需要輸入兩次,第二次的作用是「確認」——重要的事情說兩次嘛!
所以,我們要新增一個confirm_password
欄位,和password
進行跨欄位驗證:確認兩者內容相同。
儘管其中的驗證邏輯非常簡單,但這正是跨欄位驗證的絕佳舞台。
Pydantic v2 引入了@model_validator
裝飾器來處理跨欄位驗證,這是對 Pydantic v1 中@root_validator
的改進和替代。
這裡的 model,指的是 Pydantic 的 BaseModel——也就是我們的 Schema,而不是 Django 的 Models。
我們透過@model_validator
來強化「新增使用者」API,加上「確認密碼」功能。
直接看修改後的程式碼:
class CreateUserRequest(Schema):
...
password: str = Field(min_length=8, examples=['password123'])
confirm_password: str = Field(
min_length=8, examples=['password123'])
...
@model_validator(mode='after')
def check_passwords_match(self) -> Self:
if self.password != self.confirm_password:
raise ValueError('密碼和確認密碼必須相同')
return self
confirm_password
欄位。@model_validator(mode='after')
裝飾器來定義跨欄位的驗證方法。
mode
總共有三種:before、after 和 wrap。其中的細節頗多,限於篇幅,本文無法展開(可能等番外篇再行補充)。self
參數代表 Schema 實例本身(從 input 資料初始化而來)。check_passwords_match
比較password
和confirm_password
欄位,如果欄位內容不相同,則拋出ValueError
。
你會發現,在這次新增「確認密碼」的功能實作中,view 函式完全沒有變動!——這正是關注點分離原則的體現。
相較於直接在 view 函式中實作驗證邏輯(需要同時修改 view 和 Schema),這樣的實作方式無疑更加乾淨、解耦。
最後,讓我們來看看,當資料驗證失敗時,會得到什麼樣的 HTTP 回應。
前兩項是上一篇已經提過的,這裡再次列出,以便相互對照及複習。
回應結果如下:
{
"detail": [
{
"type": "string_too_short",
"loc": [
"body",
"payload",
"password"
],
"msg": "String should have at least 8 characters",
"ctx": {
"min_length": 8
}
}
]
}
這是 Django Ninja 捕捉 Pydantic 驗證錯誤所給出的「系統級」回應,狀態碼為 422。
輸入的密碼中沒有數字,回應結果如下:
{
"detail": [
{
"type": "value_error",
"loc": [
"body",
"payload",
"password"
],
"msg": "Value error, 密碼必須包含至少一個數字",
"ctx": {
"error": "密碼必須包含至少一個數字"
}
}
]
}
好像差不多耶?沒錯,因為這也是 Django Ninja 的自動回應格式——除了錯誤訊息中包含我們自定義的內容。
但事實上,這是因為我們在驗證方法中拋出的是ValueError
,所以 Django Ninja 會自動幫你處理。
類似的回應也發生在確認密碼不一致時:
{
"detail": [
{
"type": "value_error",
"loc": [
"body",
"payload"
],
"msg": "Value error, 密碼和確認密碼必須相同",
"ctx": {
"error": "密碼和確認密碼必須相同"
}
}
]
}
那如果拋出的別種錯誤,比如 Django 的ValidationError
,甚至是我們自己定義的錯誤,Django Ninja 還會自動處理嗎?
答案是:不會。
你會得到「500 Internal Server Error」——這將是我們下下篇的重點。
本文中,我們介紹了如何透過@model_validator
來實現跨欄位驗證的需求,同時落實關注點分離原則。
學習完這兩篇以後,你對 Django Ninja 資料驗證的了解,已經超越大部分人。
接下來,我們將深入探討,當資料驗證失敗時,要如何優雅地處理錯誤——並回應,以提升 API 的使用體驗。
本文同步發表於我的部落格——Code and Me